实验一 8086虚拟IO接口

实验日期:2025/10/16

emu8086

emu8086软件

emu8086是款学习汇编语言的软件,它既可以用来编写汇编程序,也能对8086cpu的功能进行模拟,它并不操作真实的硬件寄存器或内存,而是操作自己内部的变量,从而达到软件层面的仿真。因此,它非常适合作为我们学习8086cpu汇编语言的工具。

除了使用emu8086,我们其实还可以使用虚拟机进行更加真实的模拟,这就需要下载安装古老的DOS操作系统,而这样的方式远远不如使用emu8086方便。

不过,emu8086仅支持8086cpu的模拟,因此,如果我们需要模拟其他型号的cpu,就需要寻找其他软件,比如PCem。

这次及之后的实验,我都将使用emu8086软件(版本4.08)作为实验环境。

界面及操作

进入emu8086,在新建文件时会有这样几个选项:
Pasted image 20251030232043.png

这四个选项分别表示四个模板:.COM文件、.EXE文件、二进制文件、引导扇区模板,它们决定了你所写的程序会被编译为哪种格式。对于初学者来说,COM template也许是最适合的选择,因为这是最简单直接的一个,程序和数据都会加载到同一个64KB的段内,不需要我们自己定义各个段的地址。

最下面的选项表示是否选择另一个更强大也更严格的编译器(Flat Assembler)进行编译,我们此处用不到这个功能,因此不需要勾选。

新建好文件后,我们能看到上方导航栏里有许多功能:
Pasted image 20251030232112.png
它们分别为:new(新建文件), open(打开文件), examples(代码示例), save(保存), compile(编译), emulate(模拟), calculator(计算器), convertor(逆向的计算器), options(选项), help(帮助),在上方还有 ascii codes 功能,可以快速查看ASCII码。

在本次实验中,我们只需用emulate功能对代码进行模拟,点击此按钮后能看到如下两个窗口,分别为源程序代码和模拟界面:
Pasted image 20251030232133.png
模拟界面的功能很多,此处只提最常用的几个功能:

run:一次运行全部代码。

single step:单步执行,每点击一次就往后执行一条指令。

reload:复位/重载,相当于还原回初始状态重新运行代码。

下方的按钮里,screen:显示虚拟屏幕,debug:修改寄存器、标志位的值,aux里的memory可以查看内存空间,stop on condition可以用来设置断点的条件,stack可以查看堆栈段的数据。不过,模拟界面本身已经把数据内容摆出来了,方便我们查看寄存器和内存数据的变化。

简单指令

大致了解了怎么使用emu8086之后,接下来通过一些简单的指令进行汇编语言的学习,同时顺便进一步学习计算机的内部结构。

0

在新建一个.COM模板的文件后,会看到预设好的如下代码:

; You may customize this and other start-up templates;
; The location of this template is c:\emu8086\inc\0_com_template.txt

org 100h
; add your code here
ret

分号;表示其后面的是注释。原始的这些注释可以直接删掉。

org指令是一条伪指令,它是Origin的缩写,表示指定程序的起始地址(段内偏移地址),所以一般只在程序的开头使用这一指令。如果没有使用org,那么程序会默认从0000H开始执行。

ret指令用于从子程序返回到调用它的程序,它通过修改ip的内容实现段内转移,在这里的作用相当于直接结束程序。

为了方便起见,接下来的代码都假定已经把这些原先设定的代码删掉了。

MOV

MOV作为可能是最为常用的操作码之一,负责数据的传送,可以将数据move进存储器或寄存器内。

通过这个指令,我们也能熟悉常用的几种寻址方式:立即寻址;直接寻址;寄存器寻址;寄存器间接寻址;寄存器相对寻址;基址、变址寻址;基址、变址、相对寻址。

代码1.

MOV AL, 16H
MOV BL, 10B
MOV CL, 16
MOV DL, 0FFH

对于立即数,有不同的表示方法。如果数字后无后缀,表示这是以十进制表示的,如代码块第3行,如果有后缀H,表示是十六进制(Hexadecimal的缩写),如第1行,如果后缀是B(Binary的缩写),表示是二进制,如第2行。需要注意的是,立即数的第一位必须是数字而不是字母,这样程序才能正常识别,所以需要对第一位原本是用字母表示的数的前面加个0,如第4行。

代码2.

MOV AX, 1234H
;MOV BL, 5678H    
;MOV BX, 56789H
MOV BX, 56H

由于x86架构采用的是小端序,所以存取数据均遵循“低位数据存到低位,高位数据存到高位”的法则,故代码块第1行会将34H存进AL,将12H存进AH。而如果数据的大小大于对应的存储空间就会出错,故第2和第3行的代码均不能成功编译,但是第4行代码是正确的。

Pasted image 20251030233130.png

代码3.

MOV AX, 10H
MOV [10H], 11H
;MOV [AX], 22H
;MOV WORD PTR [AX], 22H 
MOV BX, AX 
;MOV [BX], 22H   ;最好不要这样写
;MOV [BX+2], 22H ;最好不要这样写
MOV WORD PTR [BX], 22H

如上,AX属于寄存器寻址,10H属于立即寻址,[10H]属于直接寻址,[BX]属于寄存器间接寻址,[BX+2]属于寄存器相对寻址。

事实上,行3和行4的代码是无法正确编译的。这是因为在x86架构中规定了可以进行内存寻址的寄存器只有:BX,BP,SI,DI以及它们的组合,而AX,CX,DX均不在此列。所以,[AX][AX+2][AX+BX]等等都是不正确的。

而即使使用BX进行内存寻址,也不一定就正确了。行6和行7的代码在我这个版本的emu8086 上可以编译,但在其他编译器上就不一定能通过编译了。因为[BX]只指定了寻址地址,但没有指定数据类型是一个字节还是别的什么,而且作为立即数的22H也没有指定类型,所以这条指令是不明确的。一般情况下,如果两个操作数中有一个是类型明确的(比如AX等16位寄存器,明确是WORD类型的),而另一个类型不明确,那么就会按照那个明确的类型执行指令,比如MOV AX, 01H,会按照AX的类型进行WORD类型的操作。而在双方都模糊的情况下,我们最好使用ptr进行强制类型声明,这样就能明确类型了。

ptr的用法是在操作数前面加上“操作类型 ptr”,如行4和行8。

现在程序能正确编译了,但是在运行后,我们还会发现这样一个问题:源程序将10H存到AX后,AX内最终的数据应该是0010H才对,但实际上看到的数据是0000H。经过单步运行调试后,发现在执行最后一条指令MOV WORD PTR [BX], 22H后,AX内的值的确是0010H,但是紧随其后程序还多执行了一条“不存在”的指令将AX赋为了0000H。通过查看模拟界面右边显示的实际执行的指令,可以发现多了这样一条:AND AL, [BX+SI]。查看中间的内存界面,我发现这是因为在编写程序时更改了内存,而这个程序没有划分CS段和DS段,段地址都默认为0100H,所以指令和数据混在一起了。

如下图,代码块行8将0100:0010H处的赋为了0020H,而这个机器码恰好表示AND AL, [BX+SI]
Pasted image 20251030233303.png
修改方式有很多,最好的方式是提前安排好段的区域,然后在程序末尾加上强制结束程序的指令,避免执行一些意料外的指令,也可以换成.EXE模板。

比如,可以把上面的代码改写成如下形式:

ASSUME CS:CODE, DS:DATA
DATA SEGMENT
	;add data here
DATA ENDS
CODE SEGMENT
	MOV AX, DATA
	MOV DS, AX  ;需要手动设置数据段的段地址
	MOV AX, 10H
	MOV [10H], 11H
	MOV BX, AX
	MOV WORD PTR [BX], 22H
CODE ENDS

编写程序

此次实验需要编写这样一个程序:

调用虚拟外设199端口,实现计数功能,在LED虚拟面板上显示100以内的所有偶数,并且每个数显示1秒钟。

调用虚拟外设

emu8086并不真正连接外部设备,而是模拟虚拟的外设。比如说会在电脑屏幕上显示一个LED面板窗口,以此演示真正LED面板的功能。

在emu8086安装路径的DEVICES文件夹内,我们能看到它能够启用的虚拟外设有哪些:
Pasted image 20251030233535.png
LED面板对应的就是LED_Display.exe这个程序。

要在汇编程序里使用这个外设,首先得启用它,再调用对应的端口:

#start=led_display.exe#
ASSUME CS:CODE
CODE SEGMENT       
	MOV AX, 1234
	OUT 199, AX   
CODE ENDS

其中,#这个字符并不是汇编语言指令,而是emu8086自身的特殊命令,表示模拟器预处理指令。#start=name.exe#表示启用对应的外设。

调用端口使用的是OUT指令,它可以将数值输出到相应的端口。另外,199这个端口对应的操作数只能是AXAL

此时运行程序,能看到虚拟LED屏上的数字变成了1234。
Pasted image 20251030233635.png

中断和延时

DOS全称为Disk Operating System,即硬盘操作系统,只关注硬盘,而BIOS全称为Basic Input Output System,即基本输入输出系统,同时接触硬件和软件,所以功能比DOS更多。

int指令会触发软件中断(内部中断),可以用来调用操作系统、BIOS提供的各种底层的功能。int指令的格式是“int 中断号”。中断号的范围是0-255,分别表示不同的功能。如中断号21H,表示调用DOS功能;15H,表示调用BIOS提供的系统服务中断。使用int 15H后,程序会根据AH的值判断需要做出的功能。例如,若AH的值为86H,则程序会提供微秒级的精确延时功能,延时时长为CX:DX(即CX的值乘以65535再加上DX的值)。因此,要延时一秒的话可以在CX里存0FH(即15),在DX里存4240H(即16960),故15x65535+16960=1000000微秒=1秒。

ASSUME CS:CODE
CODE SEGMENT
	MOV AH, 86H
	MOV CX, 000FH
	MOV DX, 4240H
	INT 15H
CODE ENDS

循环和跳转

要实现循环的程序,就需要用到跳转指令。程序跳转通常是由两条指令实现的,一个是cmp(compare缩写),另一个是jz/je/jne……等一系列跳转指令。

cmp会比较其后的两个操作数,并修改标志位。紧随其后的jump系列指令将根据这些标志位来判断是否满足跳转的条件。

jump系列的指令分为无条件跳转(jmp指令)和条件跳转。

常用的条件跳转指令有:jz(jump if zero),je(jump if equal),jg(jump if greater),jl(jump if less)……

例如,下面的代码能够实现循环跳转到function位置4次。

ASSUME CS:CODE
CODE SEGMENT
	MOV AX, 0H
	MOV CX, 0H
	FUNCTION:
		ADD AX, CX
		ADD CX, 01H
		CMP CX, 5
		JL FUNCTION
CODE ENDS

实验程序

下面的程序能够实现:调用虚拟外设199端口,实现计数功能,在LED虚拟面板上显示100以内的所有偶数,并且每个数显示1秒钟。

#start=led_display.exe#
ASSUME CS:CODE
CODE SEGMENT
	MOV CX, 0FH
	MOV DX, 4240H
	MOV BX, 0
	MOV AX, 0
	LED:
		OUT 199, AX
		MOV BX, AX
		ADD BX, 2
		MOV AH, 86H
		INT 15H
		MOV AX, BX
		CMP BX, 100
		JL LED
CODE ENDS

运行程序后的截图如下:
Pasted image 20251030234035.png